本文主要會談到
JavaScript 並不像 Java、C++ 這些知名的物件導向語言具有「類別」(class)來區分概念與實體(instance)或天生具有繼承的能力,而只有「物件」,因此只能利用設計模式來模擬這些功能。本文就來探討在 JavaScript 世界中,到底是怎麼實現物件導向的概念的?
首先要有個模子,我們稱它為類別,而當前面有 new 的時候,可看成是建構子(constructor),接著用這個建構子做初始化,進而建立(new)實體。
...
...
...
...
如下,建構子 Book 產出實體 ydkjs_1 和 ydkjs_2。
function Book(name, pNum) {
this.name = name; // 書名
this.pNum = pNum; // 頁數
this.comment = null; // 評價
this.setComments = function(comment) {
this.comment = comment;
}
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
ydkjs_1.setComments('好書!');
ydkjs_1.comment // "好書!"
ydkjs_1.setComments === ydkjs_2.setComments // false
共用的屬性或方法,不用每次都幫實體建立一份,提出來放到 prototype 即可。承上,將 setComments 這個共用的方法放到 Book.prototype
,暫且稱它為 Book 的原型。
function Book(name, pNum) {
this.name = name; // 書名
this.pNum = pNum; // 頁數
this.comment = null; // 評等
}
Book.prototype.setComments = function(comment) {
this.comment = comment;
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
ydkjs_1.setComments('好書!');
ydkjs_1.comment // "好書!"
ydkjs_2.setComments('超好書!');
ydkjs_2.comment // "超好書!"
ydkjs_1.setComments === ydkjs_2.setComments // true,確認是同一個函式!
在這裡都是在設定自己建立的物件的原型!不要嘗試修改預設的原生原型(例如:String.prototype
),也不要無條件地擴充原生原型,若要擴充也應撰寫符合規格的測試程式。另,不要使用原生原型當成變數的初始值,以避免無意間的修改。
.__proto__
指向建構子的 prototype,形成原型串鏈。在前面巢狀範疇的部份提到「若在目前執行的範疇找不到這個變數的時候,就往外層的範疇搜尋,持續搜尋直到找到為止,或直到最外層的全域範疇」,同理,當查找物件的屬性或方法時,若在本身這個物件找不到的時候,就會往更上一層物件尋找,直到串鏈尾端 Object.prototype
,若無法找到就回傳 undefined,而這個尋找的脈絡就是依循著 .__proto__
這個原型串鏈(prototype chain)來找--每個物件在建立之初都會有個 .__proto__
(dunder proto)內部屬性,它可用來存取另一個相連物件內部屬性 [[Prototype]]
的值,而 [[Prototype]]
存放其建構子原型的位置。
如下範例,ydkjs_1.__proto__
所存的參考即指向 Book.prototype
的位置。
function Book(name, pNum) {
this.name = name; // 書名
this.pNum = pNum; // 頁數
this.comment = null; // 評等
}
Book.prototype.setComments = function(comment) {
this.comment = comment;
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
ydkjs_1.__proto__ === Book.prototype // true
模型圖。
由於在 ydkjs_1 是找不到方法 setComments 的,因此就會循著 .__proto__
找到 Book.prototype 而找到方法 setComments,也因為原型串鏈,讓 JavaScript 可達到類似其他物件導向語言般的使用類別、繼承的功能。
備註,使用 .__proto__
來取得 [[Prototype]]
似乎太暴力了(畢竟人家是內部屬性嘛),還是改用 Object.getPrototypeOf(..)
來得優雅,其中 Object.getPrototypeOf(..)
會回傳 .__proto__
的值。
ydkjs_1.__proto__ === Book.prototype // true
Object.getPrototypeOf(ydkjs_1) === Book.prototype // true
...
...
接著來看幾個疑難雜症。
可用 hasOwnProperty
檢查屬性是屬於當前物件,還是位於原型串鏈中。
ydkjs_1.hasOwnProperty('name') // true
ydkjs_1.hasOwnProperty('setComments') // false
name 的確是存在於物件 ydkjs_1 中的,而 setComments
並不在物件 ydkjs_1 中,是在原型串鏈中。
注意
prop in obj
會檢查整個原型串鏈且為可列舉的屬性 。prop in obj
會檢查整個原型串鏈,不管屬性是否可列舉。範例如下。
function Book(name, pNum) {
this.name = name; // 書名
this.pNum = pNum; // 頁數
this.comment = null; // 評等
}
Book.prototype.setComments = function(comment) {
this.comment = comment;
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
Object.defineProperty(ydkjs_1, 'hello', {
value: 'world',
writable: true,
configurable: true,
enumerable: false, // 設定 hello 為不可列舉的屬性
});
由於 for loop prop in obj
會檢查整個原型串鏈且為可列舉的屬性,因此除了 hello 之外,其它的屬性都會被列出來。
for (var prop in ydkjs_1) {
console.log(prop);
}
結果得到
name
pNum
comment
setComments
承上,prop in obj
會檢查整個原型串鏈,不管屬性是否可列舉。
'hello' in ydkjs_1 // true
'name' in ydkjs_1 // true
更多關於檢視屬性是否存在的範例可參考這裡。
instanceof
檢查物件是否為指定的建構子所建立的實體,位於 instanceof
左邊的運算元是物件,右邊的是函式,若左邊的物件是由右邊函式所產生的,則會回傳 true,否則為 false。instanceof
可檢查整條原型串鏈的繼承世系,這在傳統的物件導向環境中稱為「內省」(introspection)或「反思」(reflection)。
function Book(name, pNum) {
this.name = name; // 書名
this.pNum = pNum; // 頁數
this.comment = null; // 評價
}
Book.prototype.setComments = function(comment) {
this.comment = comment;
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
ydkjs_1 與 ydkjs_2 都是由 Book 建立出來的實體,而 Book 也是由 Object 與 Function 建立出來的,因此都會得到 true。最後舉個反例,window 不是由 Book 建立出來的,因此得到 false。
ydkjs_1 instanceof Book // true
ydkjs_2 instanceof Book // true
ydkjs_1 instanceof Object // true
ydkjs_1 instanceof Function // true
ydkjs_2 instanceof Object // true
ydkjs_2 instanceof Function // true
window instanceof Book // false
window instanceof Window // true
另外一個方法是使用 isPrototypeOf
,它可檢視運算子左邊的物件是否出現於右邊物件的原型串鏈中。與 instanceof
不同之處只在於運算元的資料型別不同而已,但功能是相同的。
再看一次這個相似的範例,Novel 繼承了 Book,並建立實體 novel。
function Book(name, pNum) {
this.name = name; // 書名
this.pNum = pNum; // 頁數
this.comment = null; // 評價
}
Book.prototype.setComments = function(comment) {
this.comment = comment;
}
function Novel(name, pNum, price) {
Book.apply(this, [name, pNum]); // Novel 繼承 Book
this.price = price;
}
Novel.prototype = Object.create(Book.prototype);
Novel.prototype.printPrice = function() {
console.log(`${this.name} is ${this.price}`);
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
var novel = new Novel('最近沒在看小說 ><', 500, 600);
我們來檢視幾個問題...
Book.prototype.isPrototypeOf(ydkjs_1) // true
Book.prototype.isPrototypeOf(novel) // true
Novel.prototype.isPrototypeOf(ydkjs_1) // false
Novel.prototype.isPrototypeOf(novel) // true
...
...
看模型圖會更清楚。
...
...
注意,這裡的繼承是指原型式繼承(prototypal inheritance)。
「原型式繼承」是指使用連結相連兩個物件而能共用屬性的方式,又稱為「差異式繼承」(differential inheritance),它模仿了傳統物件導向語言的類別方法,而達到繼承的功能。
說明
Novel.prototype = Object.create(Book.prototype);
的 Object.create
建立了一個新物件(稱呼它為 O),並將 O.__proto__
設定為 Book.prototype,因此我們可以想成藉由 O 這個橋樑,讓 Novel.prototype.constructor === Book
。setPrototypeOf
來設定 [[Prototype]]
內部屬性的值。// pre-ES6
// throws away default existing `Novel.prototype`
Novel.prototype = Object.create(Book.prototype);
// ES6+
// modifies existing `Novel.prototype`
Object.setPrototypeOf(Novel.prototype, Book.prototype);
...
...
那麼,如果反過來想要取得物件的 [[Prototype]]
的值呢?那就可以用 Object.getPrototypeOf
。
ydkjs_1 的 [[Prototype]]
值是?
Object.getPrototypeOf(ydkjs_1) === Book.prototype // true
或等同直接使用 .__proto__
取得 [[Prototype]]
的值,也是可行的。
ydkjs_1.__proto__ === Book.prototype // true
instanceof
檢查物件是否為指定的建構子所建立的實體」,但其實 instanceof
所檢視的是物件的內部屬性 [[Prototype]]
(或說是 __proto__
)所形成的整條原型串鏈中,是否能找到其建構子原型。例如:ydkjs_1 與 ydkjs_2 的 __proto__
屬性是否為 Book.prototype
?(答案是肯定的)Function.__proto__
指向 Object.prototype
,而 Object.__proto__
也指向 Function.prototype
。承上範例,針對這整條原型串鏈,我們就拿它來檢查看看...
ydkjs_1.__proto__ === Book.prototype // true
Book.__proto__ === Function.prototype // true
Book.prototype.__proto__ === Object.prototype // true
Object.prototype.__proto__ // null
因此,Object.prototype 物件就是整條串鏈的最頂端了。我們可想像成,在查找變數時,最後的終點就是全域範疇了。
Object.prototype 這個物件含有很多常用的屬性和方法,例如:toString、valueOf 等,這也就是為什麼所有的物件都能使用這些功能的原因。
查找物件的屬性或方法時要注意設定與遮蔽的問題。
我們可能遇過以下這種狀況...
物件 obj 有屬性 counter 作為計數器,而 anotherObj 無此屬性且原型串列的參考指向 obj。可能是一時手誤吧,居然將 anotherObj.counter
當計數器做遞增,之後在程式某處分別印出 obj.counter
與 anotherObj.counter
,發現居然所存的值是不一樣的!這到底發生了什麼事呢?
const obj = {
counter: 0,
};
const anotherObj = Object.create(obj);
anotherObj.counter++; // 一時手誤,應改為 obj.counter++
obj.counter // 0
anotherObj.counter // 1
obj.counter++;
anotherObj.counter++;
obj.counter // 1
anotherObj.counter // 2
目前已知,anotherObj 並無 counter 屬性,而 counter 屬性位於原型串鏈 [[Prototype]]
更上一層 的 obj 之內。當使用指定運算子更新 counter 屬性值的時候,會依照以下規則來決定處理的方式
在上面的這個例子中,是屬於狀況「1」,因此目前在 obj 與 anotherObj 兩物件上都具有 counter 屬性了。解法是小心一點,不要再手誤了!
答案是「不必」。
Object.create(..)
可將兩個物件連結起來,如下,Object.create(..)
可建立一個新物件 coolPerson,連結到指定的物件 person,意即設定 coolPerson.__proto__
指向 person。
var person = {
name: null,
sayHi: function(name) {
this.name = name;
console.log(`Hi, I am ${this.name}`);
}
};
var coolPerson = Object.create(person); // coolPerson.__proto__ === person
coolPerson.sayHi('Jack'); // Hi, I am Jack
備註,若使用 Object.creat(null)
建立一個空物件,那它就真的非常空,裡面不含任何屬性,因此也就沒有 .__proto__
或 .constructor
可用了,通常會單純當成存資料用的物件而已。
var empty = Object.create(null);
empty // {}
empty.__proto__ // undefined--很空,什麼都沒有!
原型串鏈的功用似乎只是當備援(fallback)之用?意即,當查找的屬性無法在當前物件找到時,就往更上一層的物件尋找。
但其實沒這麼簡單,我們在下一篇文章「行為委派」會看到它的強大之處,例如:讓物件建立平等的委派關係以取得屬性和方法、實作更簡單易懂的設計模式等,敬請期待。
看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到...
.__proto__
而形成的物件到物件的連結串連,當查找物件屬性時,若在本身這個物件找不到,就往更上一層物件尋找,直到串鏈尾端,若無法找到就回傳 undefined。.__proto__
存放的即為其建構子原型的參考。Object.getPrototypeOf
可取得物件的 [[Prototype]]
的值;Object.setPrototypeOf
可設定物件的 [[Prototype]]
的值。Object.prototype
就整條原型串鏈的終點。Object.create(..)
可將兩個物件連結起來。...
...
最後再次附上本文範例的模型圖。
完整程式碼。
function Book(name, pNum) {
this.name = name; // 書名
this.pNum = pNum; // 頁數
this.comment = null; // 評價
}
Book.prototype.setComments = function(comment) {
this.comment = comment;
}
function Novel(name, pNum, price) {
Book.apply(this, [name, pNum]);
this.price = price;
}
Novel.prototype = Object.create(Book.prototype);
Novel.prototype.printPrice = function() {
console.log(`${this.name} is ${this.price}`);
}
var ydkjs_1 = new Book('導讀,型別與文法', 257);
var ydkjs_2 = new Book('範疇與閉包 / this 與物件原型', 251);
var novel = new Novel('最近沒在看小說 ><', 500, 600);
同步發表於部落格。